Skip to content

feat: RU/EN i18n with header language switcher - #42

Merged
axisrow merged 13 commits into
mainfrom
ao/axisrow.github.io-29/i18n-ru-en
Aug 14, 2026
Merged

feat: RU/EN i18n with header language switcher#42
axisrow merged 13 commits into
mainfrom
ao/axisrow.github.io-29/i18n-ru-en

Conversation

@axisrow

@axisrow axisrow commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Summary

Implements client-side i18n (Russian/English) per #41: a data-i18n /
data-i18n-attr / data-i18n-meta engine (i18n.js), a header language
switcher styled like the existing theme toggle, and no-FOUC language
resolution using the same pattern as the existing theme bootstrap script.

  • i18n.js: dependency-free engine exposing window.PortfolioI18n.
    Dictionaries are grouped by section (hero.*, nav.*, stars.*, …),
    meaningfully keyed rather than key1/key2. Language resolves from
    localStoragenavigator.language → RU fallback (per the issue), and
    document.documentElement.lang updates on every switch.
  • Switcher: in the topbar between the theme toggle and the GitHub link,
    same collapsed-<select>-behind-an-icon-button technique as the theme
    control (#37/#38 style), full keyboard support and visible focus-ring.
  • No FOUC: index.html ships English text inline (unchanged from
    before, and what the smoke tests pin). The <head> bootstrap script
    resolves the visitor's language before paint exactly like it already does
    for the theme; for any non-English result it adds .lang-loading to
    <html>, which hides <body> until i18n.js (loaded at the end of body)
    finishes its translation pass and removes the class.
  • Coverage: hero, nav, stars/projects section chrome, open source,
    experience, about, contact, footer, aria-labels, and meta tags
    (<title>, description, OG, Twitter). main.js's mobile-menu
    aria-label now goes through PortfolioI18n.translate instead of a
    hardcoded English string, so it stays translated after toggling.
  • Bot-managed sections: mirrored the same data-i18n markers into
    profile/sync/templates/{stars,projects}.html.j2 so the next daily sync
    doesn't silently drop the translation markup; verified the rendered
    templates stay structurally byte-identical to index.html's marker
    blocks (only bot-generated numbers/dates differ).
  • Scoped out: project/contribution data (repo names, descriptions,
    dates, counts) stays in English — it's bot-generated content data from
    profile/projects.json, not UI copy. Translating that dataset is a
    larger, separate effort; noting it here as a deliberate scope decision
    rather than an oversight.

Tests

  • tests/site-smoke.test.mjs: dictionary key coverage against every
    data-i18n/-attr/-meta key in the markup (index.html + both .j2
    templates), EN/RU key-set parity, switcher markup/position, FOUC-guard
    wiring, and a functional applyTranslations/setLanguage test against a
    minimal fake DOM.
  • Updated the two existing tests that pinned the literal Momentum/
    Selected Work eyebrow text (tests/site-smoke.test.mjs,
    profile/tests/test_site_sync.py) to match the new
    data-i18n-wrapped markup — same visible text, no other existing
    assertions changed.
  • npm test now also node --checks i18n.js.
  • npm test (44/44) and python3 -m pytest profile/tests/ (42/42) pass.
  • Manually verified in Chrome: language switch RU⇄EN across every section,
    dark + light theme, mobile-menu aria-label translation, no console
    errors, and the marker-block/template structural match via a local Jinja
    render.

Closes #41

🤖 Generated with Claude Code

- Add i18n.js: a small, dependency-free i18n engine exposing
  window.PortfolioI18n. Dictionaries are grouped by section
  (hero.*, nav.*, stars.*, ...), keyed meaningfully rather than
  key1/key2. Language resolves from localStorage, then
  navigator.language, falling back to RU (per issue #41), and
  is applied via data-i18n (textContent), data-i18n-attr
  (title/aria-label/... attributes), and data-i18n-meta
  (title/meta content) markers.
- Add a language switcher to the topbar, styled like the
  existing theme toggle (collapsed native <select> behind a
  round icon button), between the theme toggle and the GitHub
  link.
- Prevent FOUC the same way the theme bootstrap already does:
  a synchronous <head> script resolves the language before
  paint and (for any non-English result) hides <body> via a
  .lang-loading class until i18n.js's translation pass removes
  it.
- Cover all visible static UI text: hero, nav, stars/projects
  section chrome, open source, experience, about, contact,
  footer, and aria-labels/meta tags (title, description, OG,
  Twitter cards). main.js's mobile-menu aria-label now goes
  through PortfolioI18n instead of hardcoded English so it
  stays translated after toggling.
- Mirror the same data-i18n markers into
  profile/sync/templates/{stars,projects}.html.j2 so the
  bot-managed sections stay translated after the next sync;
  verified the rendered templates stay structurally identical
  to index.html's marker blocks.
- Project/contribution data (repo names, descriptions, dates,
  counts) stays in English as bot-generated content data, not
  UI copy — translating that dataset is a larger, separate
  effort and out of scope here.
- Add i18n coverage to tests/site-smoke.test.mjs: dictionary
  key coverage against every data-i18n/-attr/-meta key in the
  markup, EN/RU key-set parity, switcher markup/position,
  FOUC-guard wiring, and a functional applyTranslations/
  setLanguage test against a minimal fake DOM. Updated the two
  existing tests (site-smoke.test.mjs, test_site_sync.py) that
  pinned the literal "Momentum"/"Selected Work" eyebrow text to
  match the new data-i18n-wrapped markup.
- npm test now also `node --check`s i18n.js.

Closes #41

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
@axisrow
axisrow force-pushed the ao/axisrow.github.io-29/i18n-ru-en branch from 72d712c to 2f2308f Compare August 14, 2026 05:12
axisrow and others added 2 commits August 14, 2026 14:36
Issue #41 explicitly lists 'proof-строки' (proof rows) in the required
translation coverage. The opensource proof-row entries (contribution
descriptions and merge status) live outside the PROFILE:PROJECTS bot
markers — they are hand-maintained markup, not bot-generated content —
so the PR's 'project/contribution data stays English' scope-out does
not cover them. Add data-i18n keys + EN/RU dictionary entries for all
5 rows and bump the i18n.js cache-busting version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
Replace the independently hardcoded en/ru literals in index.html's
pre-paint bootstrap script and i18n.js's SUPPORTED/DEFAULT_LANG with
a shared <meta name="i18n-languages"> tag, following the same pattern
already used for demoscene-base. Prevents the two lists from silently
diverging if a language is ever added to one and not the other.

Falls back to the current en/ru pair when the meta tag is absent
(e.g. i18n.js's vm sandbox in tests/site-smoke.test.mjs has no real
document), so existing tests are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
@axisrow

axisrow commented Aug 14, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 1) — round f5fce9db-ed2c-403b-884a-3bb67367f3e4

Reviewed locally (/review + Codex companion), no bots pinged.

Verdict Reviewer Finding Location
HALLUCINATION codex Effect-skins.js loads before i18n.js and makes no network calls, so it cannot delay the FOUC-guard removal; the underlying claim was not supported by the code. index.html:69
SKIP claude Static markup hardcodes the language-icon as EN, but that mirrors the intentional English fallback markup and the icon syncs before first paint — no visible mismatch. index.html:101
FIX claude Supported-languages/default-language literals were duplicated between the head bootstrap script and i18n.js; unified them behind a shared meta tag. index.html, i18n.js
SKIP claude If a translation call threw mid-loop the DOM could stay partially translated; verified the dictionaries cannot produce a missing key given the existing coverage test, so the path is unreachable today. i18n.js

Resolution: Fixed the FIX finding in ef5da27 (shared <meta name="i18n-languages"> source of truth). Full test suite green (44 JS + 42 Python).

@axisrow

axisrow commented Aug 14, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 1, round 2) — round f0091725-60c7-4560-bd54-68ae6b9ecfb0

Reviewed locally (/review + Codex companion), no bots pinged.

Verdict Reviewer Finding Location
SKIP codex + claude Both reviewers raised the theoretical case of the i18n script failing to load, permanently hiding the page; this is a real but low-probability single-origin static-hosting edge case already accepted by the existing FOUC-guard design, so no code change was made this round. index.html, i18n.js
SKIP claude Re-confirmed: the static language-icon fallback text is consistent with the rest of the English fallback markup and updates synchronously before first paint — no action needed. index.html
SKIP claude The internal translation lookup safely falls back to the default language dictionary even when no language is set yet — confirmed not a bug. main.js, i18n.js
SKIP claude The round-1 fix reads the shared meta tag from two places using near-identical parsing code; this mirrors an existing pattern already used for theme bootstrapping elsewhere in the file, so left as-is. index.html, i18n.js

Resolution: No FIX verdicts this round — this is the final review round for cycle 1. Full test suite green (44 JS + 42 Python).

@axisrow

axisrow commented Aug 14, 2026

Copy link
Copy Markdown
Owner Author

📋 Review summary — all cycles

Cycle Reviewer Finding Verdict Resolution
1 codex A claim that a network-bound asset preceding i18n.js could block its execution and permanently hide the page did not hold up — the asset in question makes no network calls at all. HALLUCINATION Refuted with evidence in triage
1 claude The default language and static English-fallback markup were consistent; no visible mismatch existed for the language-toggle icon. SKIP Left as-is
1 claude The supported-languages list and default language were hardcoded independently in two files, risking future silent drift if a language were ever added to only one. FIX Fixed in ef5da27 (shared meta tag)
1 claude A translation-loop exception mid-render could theoretically leave the DOM partially translated, but the dictionaries cannot produce a missing key given existing test coverage, so the path has no live trigger. SKIP Left as-is (unreachable given test coverage)
2 codex + claude If the i18n script itself failed to load or parse (network blip, syntax error) the page could stay hidden indefinitely; assessed as a low-probability single-origin static-hosting edge case matching an already-accepted design tradeoff in the FOUC guard. SKIP Left as-is (accepted design tradeoff)
2 claude The default language and static English-fallback markup were consistent; no visible mismatch existed for the language-toggle icon. SKIP Re-confirmed
2 claude The internal translation lookup falls back safely to the default-language dictionary even when no language value is set yet. SKIP Left as-is (not a bug)
2 claude The shared language-list meta tag is now read independently in two places using near-duplicate parsing code, mirroring an existing pattern already used for theme bootstrapping. SKIP Left as-is (mirrors existing pattern)

Totals: 1 FIX (resolved), 6 SKIP, 1 HALLUCINATION (refuted). Full test suite green throughout (44 JS + 42 Python).

axisrow and others added 9 commits August 14, 2026 15:03
EN changes:
- hero.availability: 'selected' → 'selectively' (remove ambiguity)
- hero.lead: add em-dash for clearer structure
- hero.startConversation: 'Start a conversation' → 'Get in touch'
- hero.footScroll: 'inspect' → 'explore'
- stars/projects: 'original repositories' → 'own repositories'
- opensource.eyebrow: 'Proof of Work' → 'Contributions' (avoid crypto connotation)
- opensource.row1Desc: describe the contribution, not the repo owner
- experience.title: 'Engineering through shipped work' → 'Built, shipped, maintained'
- about.copy1: 'boring operations' → 'uneventful operations'
- contact: rewrite CTA from imperative to question form

RU changes:
- Fix 'starred projects' mistranslation: 'в избранном' → 'со звёздами' (4 places)
- Fix 'выборочных' (statistical sampling) → 'отдельных' (selective)
- Fix 'оригинальные' calque → 'собственные' (own)
- Fix 'Звёзды во времени' calque → 'Рост звёзд'
- Fix 'Proof of Work' → 'Вклад в open source'
- Fix 'Побочный проект' (negative) → describe actual contribution
- Fix 'Unix-подобная' (Unix-like) → 'в Unix-стиле' (Unix-friendly)
- Fix 'выдерживают продакшен' calque → 'работает в продакшене'
- Fix 'Начать разговор' literal → 'Написать мне'
- Fix 'Приносите' imperative → question form CTA
- Fix 'контрибьюций' jargon → 'сотрудничеству'
- Fix 'предсказуемую эксплуатацию' officialese → 'спокойную эксплуатацию'
- Fix 'Инженерия через реализованную работу' → 'Создано, выпущено, поддерживается'

Also updates inline EN text in index.html and Jinja templates
to stay in sync with i18n.js EN dictionary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
…r eyebrow

- Revert about.copy1 RU: 'спокойную' back to 'предсказуемую эксплуатацию'
  (engineering audience, not casual)
- Change opensource.eyebrow RU: 'Вклад в open source' → 'Принятые PR'
  (the section is about merged PRs, not open-source philosophy)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
Headings and short label phrases don't take a terminal period
in either English or Russian typographic convention.

Affects: stars.title, projects.title, projects.captionStrong,
opensource.title, experience.title, about.title — both EN and RU
dictionaries, inline HTML, and Jinja templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
.availability span styled ALL spans as green dots — both the
decorative indicator and the text span got width:7px, height:7px,
border-radius:50% and a green background. The text span collapsed
into a second green dot, leaving the label text overflowing.

Narrow the selector to .availability span:first-child so only
the empty decorative <span> becomes the dot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
…ths)

- Add data-i18n attributes to project group titles, card descriptions,
  stars section-note (split around dynamic values), SVG month labels,
  and experience timeline-org in both index.html and Jinja templates
- Add ~29 new i18n keys to both EN and RU dictionaries:
  stars.notePrefix/Middle/Suffix, 12 month.* keys, 3 projects.group.*
  keys, 7 projects.desc.* keys, experience.item2Org
- Fix test: regex for fork-stars assertion tolerates span markup
- Fix test: filter Jinja template expressions from i18n key collection
- Bump i18n.js cache-busting version

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
Drop the section-note disclaimer about 5 fork stars — merge all stars
into one total (108) for a cleaner presentation.

- Replace notePrefix/noteMiddle/noteSuffix with a single sectionNote key
- Use stats.stars_earned (108) instead of latest_total (103) in template
- Update currentLabel from 'own repositories' → 'all repositories'
- Update SVG desc to match the combined total
- Mirror changes in index.html, i18n.js (both EN and RU), and test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
Closes two gaps found while verifying PR #42 against issue #41:

- The stars SVG chart's <title>/<desc> (the only screen-reader-accessible
  description of the chart, via aria-labelledby) stayed hardcoded in
  English regardless of the selected language.
- meta.description/meta.ogDescription baked PR/star/starred counts as
  dictionary literals, so switching language after a bot-sync run would
  overwrite the freshly-synced numbers in the HTML with stale ones from
  i18n.js.

Adds a small {placeholder} interpolation layer to applyTranslations via a
new data-i18n-vars attribute (JSON on the node), and a readProfileVars()
helper that sources meta description counts live from the existing
<span data-profile-value> nodes profile/sync/apply_site_fragments.py
already keeps current — so there's still one source of truth for those
numbers, not two.

- i18n.js: interpolate(), readVars(), readProfileVars(); stars.chartTitle/
  stars.chartDesc dictionary keys (en/ru); meta.description/ogDescription
  now use {prs}/{stars}/{starred} placeholders instead of literal numbers.
- index.html + profile/sync/templates/stars.html.j2: SVG <title>/<desc>
  wired with data-i18n-meta + data-i18n-vars, kept structurally identical
  per the bot-sync marker contract.
- tests/site-smoke.test.mjs: 3 new tests covering interpolation, the live
  profile-value substitution, and chart title/desc markup in both
  index.html and the template. 47/47 JS + 42/42 Python tests pass.
- Cache-busting version bumped for i18n.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
Two high-severity findings from Codex adversarial review of PR #42:

1. If i18n.js never runs (blocked by an ad-blocker/CSP, network failure,
   or a stale/missing deploy artifact), the .lang-loading class added by
   the <head> bootstrap script for any non-English language was never
   removed — styles.css's `.lang-loading body { visibility: hidden; }`
   left the page permanently blank. Since RU is the default language,
   this meant a full blackout for most visitors on any such failure.
   Fixed with an independent setTimeout(2000ms) fail-open in the same
   bootstrap script, trading a possible flash of untranslated English
   for a page that's never permanently blank.

2. The stars section's copy ('all repositories', current-total swatch,
   and the new SVG <desc> from the previous commit) all state
   stats.stars_earned (original + fork stars), but the plotted polyline
   came from stars-history.json's daily entries, which — per its own
   documented scope ("Original axisrow repositories; forks excluded") —
   never include fork stars. The chart's endpoint (103) therefore never
   matched the number the page said it ended at (108). Since fork stars
   have no per-day history (GitHub's stargazer-events API doesn't expose
   one), chart_data() now takes a fork_stars offset applied uniformly to
   every plotted point, so the line's endpoint always equals
   stats_earned — the page's 'all repositories' framing becomes accurate
   rather than aspirational.

- index.html: fail-open setTimeout for the lang-loading guard.
- profile/sync/generate.py: chart_data(history, fork_stars=0); load_history
  threads cfg.stats.fork_stars through.
- profile/projects.json: _note updated to describe the offset instead of
  the old (now-fixed) discrepancy.
- profile/tests/test_generate.py: 2 new tests (offset applied to every
  point + ceiling scale, and default/explicit-zero parity with prior
  behavior); existing endpoint test extended to assert chart.latest_total
  == stats_earned.
- tests/site-smoke.test.mjs: new test pinning the fail-open setTimeout
  in the bootstrap script.
- 48/48 JS + 44/44 Python tests pass. Mutation-checked both fixes by
  temporarily reverting each and confirming the new tests catch the
  regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
@axisrow

axisrow commented Aug 14, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 1) — round 161c4200-201c-40ce-b84b-089f670d0578

Reviewed locally (/review + Codex companion), no bots pinged.

Verdict Reviewer Finding Location
FIX codex Non-English visitors would see a permanently blank page if the i18n script never loaded, since nothing else removed the loading guard. index.html:78
FIX codex The stars chart line ended at a lower total than the number the page text next to it claimed, because fork stars were excluded from the plotted series. profile/sync/templates/stars.html.j2:9

Both fixed in 17ea8b9 with tests + mutation checks; /review found no issues this round.

Critical finding from Codex adversarial review (cycle 2): index.html now
depends on i18n.js (added earlier in this PR), but PUBLIC_FILES in
profile/sync/build_pages.py — the allowlist the publish workflow uses to
build .pages-dist — never included it. The deployed artifact would have
silently omitted the file: production requests a nonexistent script,
localization never runs (the language selector stays inert), and for
default-RU visitors the page relies entirely on the fail-open setTimeout
from the previous commit to avoid staying permanently blank.

- profile/sync/build_pages.py: add "i18n.js" to PUBLIC_FILES.
- profile/tests/test_build_pages.py: new regression test asserting every
  local <script src="..."> index.html references is present in
  PUBLIC_FILES, so a future script addition can't repeat this gap.
  Mutation-checked by temporarily reverting the allowlist change and
  confirming the new test fails.
- 48/48 JS + 45/45 Python tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
@axisrow

axisrow commented Aug 14, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 2) — round 63b5852c-1610-4091-b7ce-20743c022839

Reviewed locally (/review + Codex companion), no bots pinged.

Verdict Reviewer Finding Location
FIX codex The new client-side translation script was never added to the deploy artifact allowlist, so production would have requested a missing file. profile/sync/build_pages.py:28
IRRELEVANT claude A stale counters file in profile/ predates this PR and disagrees with the hand-tuned hero numbers already on main, unrelated to this diff. profile/projects.json:13-14

FIX fixed in df1556a with a regression test + mutation check. The projects.json/index.html counter mismatch predates this PR (present on main before any commit here) and is out of scope for this i18n change; flagging separately to the user rather than editing bot-owned config as a drive-by.

@axisrow

axisrow commented Aug 14, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 3) — round b98fc0ef-556c-4dfc-a4c9-d23e65b9b1b2

Reviewed locally (/review + Codex companion), no bots pinged.

Verdict Reviewer Finding Location
SKIP claude The chart offset shifts the whole historical line by the current fork-star count, a documented tradeoff chosen deliberately in an earlier round, not a defect. profile/sync/generate.py:142
SKIP claude A project group without a matching dictionary entry would render its raw untranslated key, a pre-existing fallback behavior unrelated to this diff. profile/sync/templates/projects.html.j2:14

Codex companion: approve, no findings. No blocking issues this round — cycle complete.

@axisrow

axisrow commented Aug 14, 2026

Copy link
Copy Markdown
Owner Author

📋 Review summary — all cycles

Cycle Reviewer Finding Verdict Resolution
1 (self-review, step 3) Stars chart SVG title/desc and meta description numbers were untranslated and could drift from bot-synced data. FIX Fixed in 9ee8584
2 codex Non-English visitors could see a permanently blank page if the i18n script failed to load. FIX Fixed in 17ea8b9
2 codex The stars chart's plotted line ended lower than the total the page text claimed, since fork stars were excluded from the series. FIX Fixed in 17ea8b9
2 claude A stale counters file predating this PR disagrees with hand-tuned hero numbers already on main. IRRELEVANT Predates this PR, out of scope
3 codex The new translation runtime was not added to the deploy artifact allowlist, so production would have requested a missing file. FIX Fixed in df1556a
3 claude The chart offset intentionally shifts the whole historical line by the current fork-star count, a deliberate tradeoff. SKIP Deliberate, documented tradeoff
3 claude A project group without a matching dictionary entry would render its raw untranslated key. SKIP Pre-existing design risk, not introduced here

Totals: 4 FIX (all resolved), 2 SKIP, 1 IRRELEVANT. 48/48 JS + 45/45 Python tests pass. CI green. Reviewed locally (/review + Codex companion) across 3 rounds; final round clean from both reviewers.

@axisrow
axisrow merged commit 4e614c4 into main Aug 14, 2026
1 check passed
@axisrow
axisrow deleted the ao/axisrow.github.io-29/i18n-ru-en branch August 14, 2026 09:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n: поддержка русского и английского с переключателем в шапке

1 participant